{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "jupyter": {
     "source_hidden": true
    }
   },
   "outputs": [],
   "source": [
    "# /// script\n",
    "# requires-python = \">=3.12\"\n",
    "# dependencies = [\n",
    "#     \"ipywidgets\",\n",
    "#     \"lonboard\",\n",
    "#     \"traitlets\",\n",
    "# ]\n",
    "# ///"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Linked Maps\n",
    "\n",
    "This notebook demonstrates how you can link two different Lonboard maps using the [`ipywidgets.observe`](https://ipywidgets.readthedocs.io/en/8.1.5/examples/Widget%20Events.html#traitlet-events) method, so panning/zooming one map will automatically pan/zoom the other map.\n",
    "\n",
    "Linked maps can be useful in a variety of situations:\n",
    "\n",
    "- Before/After maps, where one map shows data before something happened and the other after the event\n",
    "- To showcase results of different processing methodologies\n",
    "- To simply present multiple maps with different data that doesn't easily fit on one map\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Dependencies\n",
    "\n",
    "Install [`uv`](https://docs.astral.sh/uv) and then launch this notebook with:\n",
    "\n",
    "```\n",
    "uvx juv run examples/linked-maps.ipynb\n",
    "```\n",
    "\n",
    "(The `uvx` command is included when installing `uv`).\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Imports\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from functools import partial\n",
    "from typing import Sequence\n",
    "\n",
    "import ipywidgets as widgets\n",
    "import traitlets\n",
    "\n",
    "from lonboard import Map\n",
    "from lonboard.basemap import CartoBasemap\n",
    "from lonboard.view_state import MapViewState"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Create the maps\n",
    "\n",
    "Because layers don't matter for this example, we are going to create two maps without any layers, one map using the Positron basemap, and another using the Dark Matter basemap.\n",
    "\n",
    "To start, the view state on the Positron map to be focused on the Gateway Arch in St. Louis Missouri, and the Dark Matter map will be centered on the Statue of Liberty in New York City, New York.\n",
    "\n",
    "We'll present the two maps side by side in an ipywidgets HBox to keep them tidy. Setting the layout of the maps to \"flex='1'\" will allow the maps to display inside the HBox.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "## Create postitron map focused on the arch\n",
    "positron_map = Map(\n",
    "    layers=[],\n",
    "    basemap_style=CartoBasemap.Positron,\n",
    "    view_state={\n",
    "        \"longitude\": -90.1849,\n",
    "        \"latitude\": 38.6245,\n",
    "        \"zoom\": 16,\n",
    "        \"pitch\": 0,\n",
    "        \"bearing\": 0,\n",
    "    },\n",
    "    layout=widgets.Layout(flex=\"1\"),\n",
    ")\n",
    "\n",
    "## Create postitron map focused on the lady liberty\n",
    "darkmatter_map = Map(\n",
    "    layers=[],\n",
    "    basemap_style=CartoBasemap.DarkMatter,\n",
    "    view_state={\n",
    "        \"longitude\": -74.04454,\n",
    "        \"latitude\": 40.6892,\n",
    "        \"zoom\": 16,\n",
    "        \"pitch\": 0,\n",
    "        \"bearing\": 0,\n",
    "    },\n",
    "    layout=widgets.Layout(flex=\"1\"),\n",
    ")\n",
    "\n",
    "maps_box = widgets.HBox([positron_map, darkmatter_map])\n",
    "maps_box"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Linking the Maps (the easy way to understand)\n",
    "\n",
    "If you haven't yet run the cells below, you'll see that you can pan/zoom the two maps independent of one another.  Panning/zooming one map will not affect the other map.  After we run the code below though, the two maps will synchronize with each other, when we pan/zoom one map, the other map will automatically match the map that was modified.\n",
    "\n",
    "To achieve the view state synchronization, we'll write two simple callback function for each of the maps. The functions will receive events from the interaction with the maps, and if the interaction with the map changed the view_state, we'll set the view_state on the other map to match the view_state of the the map that we interacted with.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def sync_positron_to_darkmatter(event: traitlets.utils.bunch.Bunch) -> None:\n",
    "    if isinstance(event.get(\"new\"), MapViewState):\n",
    "        darkmatter_map.view_state = positron_map.view_state\n",
    "\n",
    "\n",
    "positron_map.observe(sync_positron_to_darkmatter)\n",
    "\n",
    "\n",
    "def sync_darkmatter_to_positron(event: traitlets.utils.bunch.Bunch) -> None:\n",
    "    if isinstance(event.get(\"new\"), MapViewState):\n",
    "        positron_map.view_state = darkmatter_map.view_state\n",
    "\n",
    "\n",
    "darkmatter_map.observe(sync_darkmatter_to_positron)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Linking the Maps (the more elegant/robust way)\n",
    "\n",
    "In the block above we are typing a lot of code, and the two functions are basically the same, just with hard coded maps to target in the functions, and we're explicitly calling the originating map's `view_state` even though the `event[\"new\"]` actually is the view state.  Additionally if we had a lot of maps to sync, this would get out of hand quickly. None of that is idea, but it makes the concept easy to understand. Below is a better way to sync the maps, albeit a bit more abstract.\n",
    "\n",
    "Luckily `functools.partial` can help us out. Instead of writing a function per map, we can write one function that take the same events from the widget, but also another parameter which is a list of Lonboard maps. Then when we register the callback function with the map's `observe()` method, we pass partial as the function and tell partial to use the `link_maps` function and provide the list of the other maps to sync with this map. This way we have one function that we wrote which can be used to sync any map with any number of other maps.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def link_maps(\n",
    "    event: traitlets.utils.bunch.Bunch,\n",
    "    other_maps: Sequence[Map] = (),\n",
    ") -> None:\n",
    "    if isinstance(event.get(\"new\"), MapViewState):\n",
    "        for lonboard_map in other_maps:\n",
    "            lonboard_map.view_state = event[\"new\"]\n",
    "\n",
    "\n",
    "positron_map.observe(partial(link_maps, other_maps=[darkmatter_map]))\n",
    "darkmatter_map.observe(partial(link_maps, other_maps=[positron_map]))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "lonboard",
   "language": "python",
   "name": "lonboard"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
